route.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. vi.mock("@/lib/auth/session", () => ({
  4. getSession: vi.fn(),
  5. }));
  6. vi.mock("@/lib/db", () => ({
  7. getDb: vi.fn(),
  8. }));
  9. vi.mock("@/models/user", () => {
  10. const USER_ROLES = Object.freeze({
  11. BRANCH: "branch",
  12. ADMIN: "admin",
  13. SUPERADMIN: "superadmin",
  14. DEV: "dev",
  15. });
  16. return {
  17. default: {
  18. findById: vi.fn(),
  19. findOne: vi.fn(),
  20. findByIdAndDelete: vi.fn(),
  21. },
  22. USER_ROLES,
  23. };
  24. });
  25. import { getSession } from "@/lib/auth/session";
  26. import { getDb } from "@/lib/db";
  27. import User from "@/models/user";
  28. import { PATCH, DELETE, dynamic } from "./route.js";
  29. function createRequestStub(body) {
  30. return {
  31. async json() {
  32. return body;
  33. },
  34. };
  35. }
  36. describe("PATCH /api/admin/users/[userId]", () => {
  37. beforeEach(() => {
  38. vi.clearAllMocks();
  39. getDb.mockResolvedValue({});
  40. });
  41. it('exports dynamic="force-dynamic"', () => {
  42. expect(dynamic).toBe("force-dynamic");
  43. });
  44. it("returns 401 when unauthenticated", async () => {
  45. getSession.mockResolvedValue(null);
  46. const res = await PATCH(createRequestStub({}), {
  47. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  48. });
  49. expect(res.status).toBe(401);
  50. expect(await res.json()).toEqual({
  51. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  52. });
  53. });
  54. it("returns 403 when authenticated but not allowed (admin)", async () => {
  55. getSession.mockResolvedValue({
  56. userId: "u1",
  57. role: "admin",
  58. branchId: null,
  59. email: "admin@example.com",
  60. });
  61. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  62. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  63. });
  64. expect(res.status).toBe(403);
  65. expect(await res.json()).toEqual({
  66. error: {
  67. message: "Forbidden",
  68. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  69. },
  70. });
  71. });
  72. it("returns 400 when JSON parsing fails", async () => {
  73. getSession.mockResolvedValue({
  74. userId: "u2",
  75. role: "superadmin",
  76. branchId: null,
  77. email: "superadmin@example.com",
  78. });
  79. const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
  80. const res = await PATCH(req, {
  81. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  82. });
  83. expect(res.status).toBe(400);
  84. expect(await res.json()).toEqual({
  85. error: {
  86. message: "Invalid request body",
  87. code: "VALIDATION_INVALID_JSON",
  88. },
  89. });
  90. });
  91. it("returns 400 when body is not an object", async () => {
  92. getSession.mockResolvedValue({
  93. userId: "u2",
  94. role: "superadmin",
  95. branchId: null,
  96. email: "superadmin@example.com",
  97. });
  98. const res = await PATCH(createRequestStub("nope"), {
  99. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  100. });
  101. expect(res.status).toBe(400);
  102. expect(await res.json()).toEqual({
  103. error: {
  104. message: "Invalid request body",
  105. code: "VALIDATION_INVALID_BODY",
  106. },
  107. });
  108. });
  109. it("returns 400 when userId param is missing", async () => {
  110. getSession.mockResolvedValue({
  111. userId: "u2",
  112. role: "dev",
  113. branchId: null,
  114. email: "dev@example.com",
  115. });
  116. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  117. params: Promise.resolve({ userId: undefined }),
  118. });
  119. expect(res.status).toBe(400);
  120. expect(await res.json()).toEqual({
  121. error: {
  122. message: "Missing required route parameter(s)",
  123. code: "VALIDATION_MISSING_PARAM",
  124. details: { params: ["userId"] },
  125. },
  126. });
  127. });
  128. it("returns 400 when userId param is invalid", async () => {
  129. getSession.mockResolvedValue({
  130. userId: "u2",
  131. role: "dev",
  132. branchId: null,
  133. email: "dev@example.com",
  134. });
  135. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  136. params: Promise.resolve({ userId: "nope" }),
  137. });
  138. expect(res.status).toBe(400);
  139. expect(await res.json()).toMatchObject({
  140. error: { code: "VALIDATION_INVALID_FIELD" },
  141. });
  142. });
  143. it("returns 404 when user does not exist", async () => {
  144. getSession.mockResolvedValue({
  145. userId: "u2",
  146. role: "superadmin",
  147. branchId: null,
  148. email: "superadmin@example.com",
  149. });
  150. User.findById.mockReturnValue({
  151. exec: vi.fn().mockResolvedValue(null),
  152. });
  153. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  154. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  155. });
  156. expect(res.status).toBe(404);
  157. expect(await res.json()).toEqual({
  158. error: {
  159. message: "Not found",
  160. code: "USER_NOT_FOUND",
  161. details: { userId: "507f1f77bcf86cd799439011" },
  162. },
  163. });
  164. });
  165. it("returns 400 when switching to role=branch without branchId (existing has none)", async () => {
  166. getSession.mockResolvedValue({
  167. userId: "u2",
  168. role: "dev",
  169. branchId: null,
  170. email: "dev@example.com",
  171. });
  172. const user = {
  173. _id: "507f1f77bcf86cd799439011",
  174. username: "x",
  175. email: "x@example.com",
  176. role: "admin",
  177. branchId: null,
  178. mustChangePassword: false,
  179. createdAt: new Date(),
  180. updatedAt: new Date(),
  181. save: vi.fn(),
  182. };
  183. User.findById.mockReturnValue({
  184. exec: vi.fn().mockResolvedValue(user),
  185. });
  186. const res = await PATCH(createRequestStub({ role: "branch" }), {
  187. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  188. });
  189. expect(res.status).toBe(400);
  190. expect(await res.json()).toEqual({
  191. error: {
  192. message: "Missing required fields",
  193. code: "VALIDATION_MISSING_FIELD",
  194. details: { fields: ["branchId"] },
  195. },
  196. });
  197. });
  198. it("returns 200 and updates fields; clears branchId for non-branch roles", async () => {
  199. getSession.mockResolvedValue({
  200. userId: "u2",
  201. role: "superadmin",
  202. branchId: null,
  203. email: "superadmin@example.com",
  204. });
  205. const user = {
  206. _id: "507f1f77bcf86cd799439011",
  207. username: "olduser",
  208. email: "old@example.com",
  209. role: "branch",
  210. branchId: "NL01",
  211. mustChangePassword: true,
  212. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  213. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  214. save: vi.fn().mockResolvedValue(true),
  215. };
  216. User.findById.mockReturnValue({
  217. exec: vi.fn().mockResolvedValue(user),
  218. });
  219. const res = await PATCH(
  220. createRequestStub({
  221. role: "admin",
  222. mustChangePassword: false,
  223. }),
  224. {
  225. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  226. },
  227. );
  228. expect(res.status).toBe(200);
  229. expect(user.role).toBe("admin");
  230. expect(user.branchId).toBe(null);
  231. expect(user.mustChangePassword).toBe(false);
  232. expect(user.save).toHaveBeenCalledTimes(1);
  233. const body = await res.json();
  234. expect(body).toMatchObject({
  235. ok: true,
  236. user: {
  237. id: "507f1f77bcf86cd799439011",
  238. username: "olduser",
  239. email: "old@example.com",
  240. role: "admin",
  241. branchId: null,
  242. mustChangePassword: false,
  243. },
  244. });
  245. });
  246. it("returns 400 when username is already taken by another user", async () => {
  247. getSession.mockResolvedValue({
  248. userId: "u2",
  249. role: "dev",
  250. branchId: null,
  251. email: "dev@example.com",
  252. });
  253. const user = {
  254. _id: "507f1f77bcf86cd799439011",
  255. username: "olduser",
  256. email: "old@example.com",
  257. role: "admin",
  258. branchId: null,
  259. mustChangePassword: false,
  260. createdAt: new Date(),
  261. updatedAt: new Date(),
  262. save: vi.fn(),
  263. };
  264. User.findById.mockReturnValue({
  265. exec: vi.fn().mockResolvedValue(user),
  266. });
  267. User.findOne.mockReturnValue({
  268. select: vi.fn().mockReturnThis(),
  269. exec: vi.fn().mockResolvedValue({ _id: "507f1f77bcf86cd799439099" }),
  270. });
  271. const res = await PATCH(createRequestStub({ username: "TakenUser" }), {
  272. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  273. });
  274. expect(res.status).toBe(400);
  275. const body = await res.json();
  276. expect(body).toEqual({
  277. error: {
  278. message: "Username already exists",
  279. code: "VALIDATION_INVALID_FIELD",
  280. details: { field: "username", value: "takenuser" },
  281. },
  282. });
  283. });
  284. });
  285. describe("DELETE /api/admin/users/[userId]", () => {
  286. beforeEach(() => {
  287. vi.clearAllMocks();
  288. getDb.mockResolvedValue({});
  289. });
  290. it("returns 401 when unauthenticated", async () => {
  291. getSession.mockResolvedValue(null);
  292. const res = await DELETE(
  293. new Request("http://localhost/api/admin/users/x"),
  294. {
  295. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  296. },
  297. );
  298. expect(res.status).toBe(401);
  299. expect(await res.json()).toEqual({
  300. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  301. });
  302. });
  303. it("returns 403 when authenticated but not allowed (admin)", async () => {
  304. getSession.mockResolvedValue({
  305. userId: "u1",
  306. role: "admin",
  307. branchId: null,
  308. email: "admin@example.com",
  309. });
  310. const res = await DELETE(
  311. new Request("http://localhost/api/admin/users/x"),
  312. {
  313. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  314. },
  315. );
  316. expect(res.status).toBe(403);
  317. expect(await res.json()).toEqual({
  318. error: {
  319. message: "Forbidden",
  320. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  321. },
  322. });
  323. expect(User.findByIdAndDelete).not.toHaveBeenCalled();
  324. });
  325. it("returns 400 for invalid userId", async () => {
  326. getSession.mockResolvedValue({
  327. userId: "u2",
  328. role: "dev",
  329. branchId: null,
  330. email: "dev@example.com",
  331. });
  332. const res = await DELETE(
  333. new Request("http://localhost/api/admin/users/x"),
  334. {
  335. params: Promise.resolve({ userId: "nope" }),
  336. },
  337. );
  338. expect(res.status).toBe(400);
  339. expect(await res.json()).toMatchObject({
  340. error: { code: "VALIDATION_INVALID_FIELD" },
  341. });
  342. });
  343. it("returns 400 when trying to delete the current user (self delete)", async () => {
  344. getSession.mockResolvedValue({
  345. userId: "507f1f77bcf86cd799439011",
  346. role: "superadmin",
  347. branchId: null,
  348. email: "superadmin@example.com",
  349. });
  350. const res = await DELETE(
  351. new Request("http://localhost/api/admin/users/x"),
  352. {
  353. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  354. },
  355. );
  356. expect(res.status).toBe(400);
  357. expect(await res.json()).toEqual({
  358. error: {
  359. message: "Cannot delete current user",
  360. code: "VALIDATION_INVALID_FIELD",
  361. details: { field: "userId", reason: "SELF_DELETE_FORBIDDEN" },
  362. },
  363. });
  364. expect(User.findByIdAndDelete).not.toHaveBeenCalled();
  365. });
  366. it("returns 404 when user does not exist", async () => {
  367. getSession.mockResolvedValue({
  368. userId: "u2",
  369. role: "dev",
  370. branchId: null,
  371. email: "dev@example.com",
  372. });
  373. User.findByIdAndDelete.mockReturnValue({
  374. select: vi.fn().mockReturnThis(),
  375. exec: vi.fn().mockResolvedValue(null),
  376. });
  377. const res = await DELETE(
  378. new Request("http://localhost/api/admin/users/x"),
  379. {
  380. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  381. },
  382. );
  383. expect(res.status).toBe(404);
  384. expect(await res.json()).toEqual({
  385. error: {
  386. message: "Not found",
  387. code: "USER_NOT_FOUND",
  388. details: { userId: "507f1f77bcf86cd799439099" },
  389. },
  390. });
  391. });
  392. it("returns 200 and deleted user payload on success", async () => {
  393. getSession.mockResolvedValue({
  394. userId: "u2",
  395. role: "superadmin",
  396. branchId: null,
  397. email: "superadmin@example.com",
  398. });
  399. User.findByIdAndDelete.mockReturnValue({
  400. select: vi.fn().mockReturnThis(),
  401. exec: vi.fn().mockResolvedValue({
  402. _id: "507f1f77bcf86cd799439099",
  403. username: "todelete",
  404. email: "todelete@example.com",
  405. role: "branch",
  406. branchId: "NL01",
  407. mustChangePassword: true,
  408. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  409. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  410. }),
  411. });
  412. const res = await DELETE(
  413. new Request("http://localhost/api/admin/users/x"),
  414. {
  415. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  416. },
  417. );
  418. expect(res.status).toBe(200);
  419. const body = await res.json();
  420. expect(body).toMatchObject({
  421. ok: true,
  422. user: {
  423. id: "507f1f77bcf86cd799439099",
  424. username: "todelete",
  425. email: "todelete@example.com",
  426. role: "branch",
  427. branchId: "NL01",
  428. mustChangePassword: true,
  429. },
  430. });
  431. });
  432. });